arnaucube
arnaucube/jellyfin-toolsPublic Self-hosted repository
jellyfin-tools/jellyfin-year-review/web/src/routes/[...path]/+page.svelte
jellyfin-year-review/web/src/routes/[...path]/+page.svelteRaw
  1<script>
  2  import { onMount } from 'svelte';
  3  import { t, message, locale } from '../../lib/i18n.js';
  4  import Poster from '../../lib/Poster.svelte';
  5  import PlatformBreakdown from '../../lib/PlatformBreakdown.svelte';
  6  import Chart from '../../lib/Chart.svelte';
  7  import RadarChart from '../../lib/RadarChart.svelte';
  8  import ViewingTimeline from '../../lib/ViewingTimeline.svelte';
  9  import RankingPosition from '../../lib/RankingPosition.svelte';
 10  import Confetti from '../../lib/Confetti.svelte';
 11
 12  let user = $state(null), years = $state([]), year = $state(''), review = $state(null);
 13  let loading = $state(true), signingIn = $state(false), error = $state(''), notice = $state('');
 14  let username = $state(''), password = $state(''), slide = $state(0);
 15  let touch = null, request = 0;
 16  let authMode = $state('jellyfin');
 17  const chapters = [
 18    { id: 0, name: t("Your viewing recap") },
 19    { id: 1, name: t("Your watch time"), availability: 'exactWatchTime' },
 20    { id: 2, name: t("Movies watched") },
 21    { id: 3, name: t("TV shows watched") },
 22    { id: 4, name: t("Unfinished movies"), availability: 'completion' },
 23    { id: 14, name: t("Shows you've watched are from platforms...") },
 24    { id: 5, name: t("Your top genres") },
 25    { id: 6, name: t("Viewing by hour") },
 26    { id: 7, name: t("Viewing by weekday") },
 27    { id: 8, name: t("Viewing by month") },
 28    { id: 9, name: t("Your devices and apps") },
 29    { id: 10, name: t("Your longest viewing session"), availability: 'marathon' },
 30    { id: 11, name: t("Your viewing profile"), availability: 'personality' },
 31    { id: 12, name: t("It's not a competition... but this is your watch-time ranking"), availability: 'ranking' },
 32    { id: 13, name: t("Your year in summary") }
 33  ];
 34  // Navigation and chapter numbering follow only the available cards for this year.
 35  let cards = $derived(chapters.filter(chapter => !chapter.availability || review?.availability?.[chapter.availability]?.available !== false));
 36  let card = $derived(cards[slide]?.id);
 37  const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'].map(value => t(value));
 38  const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(value => t(value));
 39  const count = (n) => new Intl.NumberFormat(locale).format(n);
 40  const hours = (n) => new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(n / 3600);
 41  const duration = (n) => t("{hours} h", { hours: hours(n) });
 42  const interactive = (target) => target instanceof Element && !!target.closest('button, input, select, a, textarea');
 43
 44  async function api(path, options = {}) {
 45    let res, body;
 46    try {
 47      res = await fetch(path, { credentials: 'same-origin', ...options });
 48      body = await res.json();
 49    } catch {
 50      throw new Error(t("The request could not be completed."));
 51    }
 52    if (!res.ok) {
 53      if (res.status === 401 && path !== '/api/login') { user = null; review = null; }
 54      throw Object.assign(new Error(message(body.error) || t("The request could not be completed.")), { status: res.status });
 55    }
 56    return body;
 57  }
 58  async function loadReview() {
 59    if (!year) return;
 60    const current = ++request;
 61    loading = true; error = ''; review = null; slide = 0;
 62    try {
 63      const data = await api(`/api/review?year=${encodeURIComponent(year)}`);
 64      if (current !== request) return;
 65      if (data.schemaVersion !== 1) throw new Error(t("This review format is not supported. Ask the operator to update the app."));
 66      review = data;
 67      history.replaceState(null, '', `/review/${year}`);
 68    } catch (e) { if (current === request) error = e.message; }
 69    finally { if (current === request) loading = false; }
 70  }
 71  async function bootstrap() {
 72    loading = true; error = '';
 73    try {
 74      const config = await api('/api/config'); authMode = config.authMode;
 75      const session = await api('/api/session'); user = session.user; years = session.years;
 76      const pathYear = location.pathname.split('/').filter(Boolean)[1];
 77      year = String(years.includes(Number(pathYear)) ? Number(pathYear) : years.at(-1) || '');
 78      if (year) await loadReview();
 79    } catch (e) { if (user || e.status !== 401) error = e.message; }
 80    finally { loading = false; }
 81  }
 82  async function login(event) {
 83    event.preventDefault(); signingIn = true; error = ''; notice = '';
 84    try {
 85      await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) });
 86      password = ''; await bootstrap();
 87    } catch (e) { error = e.message; }
 88    finally { password = ''; signingIn = false; }
 89  }
 90  async function logout() {
 91    notice = ''; error = '';
 92    try { await api('/api/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); }
 93    catch (e) { notice = e.message; }
 94    finally { request++; user = null; review = null; years = []; password = ''; history.replaceState(null, '', '/'); }
 95  }
 96  function navigate(delta) { slide = Math.max(0, Math.min(cards.length - 1, slide + delta)); }
 97  function keydown(event) {
 98    if (!review || interactive(event.target) || event.altKey || event.ctrlKey || event.metaKey) return;
 99    if (event.key === 'ArrowRight') { event.preventDefault(); navigate(1); }
100    if (event.key === 'ArrowLeft') { event.preventDefault(); navigate(-1); }
101    if (event.key === 'Home') { event.preventDefault(); slide = 0; }
102    if (event.key === 'End') { event.preventDefault(); slide = cards.length - 1; }
103  }
104  function pointerdown(event) { if (event.pointerType === 'touch' && !interactive(event.target)) touch = { x: event.clientX, y: event.clientY }; }
105  function pointerup(event) {
106    if (!touch) return;
107    const dx = event.clientX - touch.x, dy = event.clientY - touch.y; touch = null;
108    if (Math.abs(dx) > 65 && Math.abs(dx) > Math.abs(dy) * 1.5) navigate(dx < 0 ? 1 : -1);
109  }
110  onMount(bootstrap);
111</script>
112
113<svelte:head><title>{t("Jellyfin Year Review")}</title><meta name="description" content={t("Your private Jellyfin year in movies and television.")} /></svelte:head>
114<svelte:window onkeydown={keydown} onpointerup={pointerup} onpointercancel={() => touch = null} />
115
116<div class="app-shell" class:login-only={!user}>
117  {#if user}
118  <header class="topbar">
119    <a class="brand" href="/" aria-label={t("Jellyfin Year Review home")}><span class="brand-icon">{t("J")}</span><span>{t("Jellyfin")} <span class="brand-light">{t("Year Review")}</span><small>{t("A YEAR WORTH REPLAYING")}</small></span></a>
120    {#if user}
121      <div class="account">
122        {#if years.length > 1}<label class="year-picker">{t("Year")}<select aria-label={t("Review year")} bind:value={year} onchange={loadReview}>{#each years as y}<option value={String(y)}>{y}</option>{/each}</select></label>{/if}
123        <button class="quiet" onclick={logout}>{t("Sign out")}</button>
124      </div>
125    {/if}
126  </header>
127  {/if}
128
129  <main>
130    {#if user}
131      {#if notice}<p class="message" role="status">{notice}</p>{/if}
132      {#if error}<div class="message error" role="alert">{error}<button class="quiet" onclick={bootstrap}>{t("Try again")}</button></div>{/if}
133    {/if}
134    {#if !user}
135      <section class="login-layout" aria-label={t("Sign in")}>
136        <form class="login-panel" onsubmit={login}>
137          <h1 style="white-space: pre-line">{t("Your year,\nreplayed.")}</h1><p>{t("Sign in with your existing Jellyfin account.")}</p>
138          {#if notice}<p class="message" role="status">{notice}</p>{/if}
139          {#if error}<div class="message error" role="alert">{error}</div>{/if}
140          <label for="username">{t("Jellyfin username")}</label><input id="username" name="username" autocomplete="username" required maxlength="256" bind:value={username} />
141          <label for="password">{t("Password")}</label><input id="password" name="password" type="password" autocomplete="current-password" bind:value={password} />
142          <button class="primary" type="submit" disabled={loading || signingIn}>{loading ? t("Loading…") : signingIn ? t("Signing in…") : t("Roll the credits")} <span aria-hidden="true">↗</span></button>
143          <small class="login-note">{authMode === 'offline' ? t("Your credentials are checked locally against a private Jellyfin account snapshot. Password changes take effect after the operator refreshes it.") : t("Your credentials are validated by your Jellyfin server. Your review stays private.")}</small>
144        </form>
145      </section>
146    {:else if loading}
147      <section class="state-screen"><span class="loading-orbit" aria-hidden="true"></span><h1>{t("Setting the scene…")}</h1><p role="status">{t("Loading your private review")}</p></section>
148    {:else if !years.length}
149      <section class="state-screen"><p class="eyebrow">{t("NOT QUITE SHOWTIME")}</p><h1>{t("Your story is on its way.")}</h1><p>{t("No review has been generated for your account. Ask your server operator to generate a year.")}</p><button class="primary" onclick={bootstrap}>{t("Check again")}</button></section>
150    {:else if review}
151      <div class="review-meta"><span>{review.year} / {user.name}</span></div>
152      <nav class="progress" aria-label={t("Review chapters")}>{#each cards as chapter, i}<button class:active={slide === i} class:visited={slide > i} aria-label="{i + 1}. {chapter.name}" aria-current={slide === i ? 'step' : undefined} onclick={() => slide = i}><span></span></button>{/each}</nav>
153      <section class="story tone-{slide % 4}" aria-roledescription={t("carousel")} aria-label={t("Your yearly review")} onpointerdown={pointerdown}>
154        <div class="story-orbit" aria-hidden="true"></div>
155        <div class="story-body" aria-live="polite" aria-atomic="true">
156          <p class="eyebrow">{t("CHAPTER {number} / {name}", { number: String(slide + 1).padStart(2, '0'), name: cards[slide].name.toLocaleUpperCase(locale) })}</p>
157          {#if card === 0}
158            <span class="year-stamp">{review.year}</span><h1>{t("Your viewing recap")}</h1><p class="story-lede">{t(review.records ? "{name}, see the movies, TV shows, and viewing habits that made up your year." : "{name}, no viewing activity was recorded for this year.", { name: user.name })}</p><button class="primary" onclick={() => navigate(1)}>{t("View my recap")} <span aria-hidden="true">→</span></button><span class="giant-play" aria-hidden="true">▶</span>
159          {:else if card === 1}
160            <h2>{t("Your watch time")}</h2><div class="big-stat">{hours(review.seconds)}<span>{t(review.availability.exactWatchTime.available ? "hours watched" : "hours watched · end-attributed estimate")}</span></div>
161            <div class="stat-grid"><div><b>{duration(review.movieSeconds)}</b><span>{t("Movies")}</span></div><div><b>{duration(review.tvSeconds)}</b><span>{t("Television")}</span></div><div><b>{count(review.records)}</b><span>{t("Activity records")}</span></div><div><b>{count(review.uniqueTitles)}</b><span>{t("Unique movies & episodes")}</span></div></div>
162          {:else if card === 2 || card === 3}
163            {@const items = card === 2 ? review.movies : review.shows}
164            <h2>{card === 2 ? t("Movies watched") : t("TV shows watched")}</h2>
165            <p>{t("Everything you watched in {year}, grouped by month.", { year: review.year })}</p>
166            <ViewingTimeline {items} year={review.year} kind={card === 2 ? 'movies' : 'shows'} />
167          {:else if card === 4}
168            <h2>{t("Unfinished movies")}</h2><p class="story-lede">{t("Movies where you watched less than {percent}% of the runtime.", { percent: review.rules.completionPercent })}</p>
169            {#if review.abandoned.length}<ol class="content-list compact">{#each review.abandoned.slice(0, 5) as item}<li><Poster {item} /><div><h3>{item.title}</h3><p>{t("{percent}% of the movie watched", { percent: Math.round(item.completionPercent) })}</p></div></li>{/each}</ol>{:else}<p class="empty-note">{review.availability.completion.available ? t("No unfinished movies found in supported records.") : t("Completion is unavailable without runtime and actual position ranges.")}</p>{/if}
170          {:else if card === 14}
171            <h2>{t("Shows you've watched are from platforms...")}</h2>
172            <p>{t("Distinct TV shows watched, grouped by platform.")}</p>
173            <PlatformBreakdown platforms={review.platforms ?? []} records={review.records} />
174          {:else if card === 5}
175            <h2>{t("Your top genres")}</h2><p>{t("Genres ranked by watch time.")}</p>
176            {#if review.genres.length}
177              {@const values = review.genres.slice(0, 7).map(v => v.seconds / 3600)}
178              {@const labels = review.genres.slice(0, 7).map(v => message(v.name))}
179              <Chart {values} {labels} metric={t("h")} />
180              <RadarChart {values} {labels} metric={t("h")} />
181            {:else}<p class="empty-note">{t("No genre history is available.")}</p>{/if}
182          {:else if card === 6}
183            <h2>{t("Viewing by hour")}</h2><p>{t("Your viewing activity across the hours of the day.")}</p><div class="hour-chart"><Chart values={review.hourly} labels={Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`)} metric={review.distributionMetric === 'watched minutes' ? t("min") : t("records")} /></div>
184          {:else if card === 7}
185            <h2>{t("Viewing by weekday")}</h2><p>{t("Your viewing activity for each day of the week.")}</p><Chart values={[...review.weekdays.slice(1), review.weekdays[0]]} labels={weekdays} metric={review.distributionMetric === 'watched minutes' ? t("min") : t("records")} />
186          {:else if card === 8}
187            <h2>{t("Viewing by month")}</h2><p>{t("Your viewing activity for each month of {year}.", { year: review.year })}</p><Chart values={review.monthly} labels={months} metric={review.distributionMetric === 'watched minutes' ? t("min") : t("records")} />
188          {:else if card === 9}
189            <h2>{t("Your devices and apps")}</h2><p>{t("Your most-used devices and apps, ranked by watch time.")}</p><div class="two-columns"><div><h3>{t("Devices")}</h3><Chart values={review.devices.slice(0, 5).map(v => v.seconds / 3600)} labels={review.devices.slice(0, 5).map(v => message(v.name))} metric={t("h")} /></div><div><h3>{t("Apps & clients")}</h3><Chart values={review.clients.slice(0, 5).map(v => v.seconds / 3600)} labels={review.clients.slice(0, 5).map(v => message(v.name))} metric={t("h")} /></div></div>
190          {:else if card === 10}
191            <h2>{t("Your longest viewing session")}</h2>{#if review.marathon}<div class="big-stat">{hours(review.marathon.watchedSeconds)}<span>{t("hours watched in your longest session")}</span></div><p class="story-lede">{t("{duration} elapsed · {records} records", { duration: duration(review.marathon.elapsedSeconds), records: count(review.marathon.records) })}</p><p>{new Date(review.marathon.start).toLocaleString(locale, { timeZone: review.timezone })}</p>{:else}<p class="empty-note">{message(review.availability.marathon.reason)}</p>{/if}
192          {:else if card === 11}
193            <h2>{t("Your viewing profile")}</h2>{#if review.personality}<span class="personality-symbol" aria-hidden="true">✳</span><h3 class="personality-name">{message(review.personality.name)}</h3><p class="story-lede">{message(review.personality.explanation)}</p>{:else}<p class="empty-note">{message(review.availability.personality.reason)}</p>{/if}
194          {:else if card === 12}
195            <h2>{t("It's not a competition... but this is your watch-time ranking")}</h2><p>{t("Your total watch time compared with other eligible viewers.")}</p>{#if review.ranking}<div class="big-stat">#{review.ranking.rank}<span>{t("of {count} eligible viewers", { count: count(review.ranking.cohort) })}</span></div><RankingPosition ranking={review.ranking} /><div class="stat-grid"><div><b>{Math.round(review.ranking.percentile)}%</b><span>{t("Viewers with less watch time")}</span></div><div><b>{duration(review.seconds)}</b><span>{t("Your total hours watched · movies & TV shows")}</span></div></div>{:else}<p class="empty-note">{message(review.availability.ranking.reason)}</p>{/if}
196          {:else}
197            <p class="year-stamp">{t("SUMMARY / {year}", { year: review.year })}</p><h2>{t("Your year in summary")}</h2><div class="stat-grid">{#if review.availability.exactWatchTime.available !== false}<div><b>{duration(review.seconds)}</b><span>{t("Your watch time")}</span></div>{/if}{#if review.availability.personality.available !== false}<div><b>{message(review.personality?.name) || t("No viewing profile")}</b><span>{t("Your viewing profile")}</span></div>{/if}</div><p class="story-lede">{t("You have reached the end of your viewing recap.")}</p><button class="primary" onclick={() => slide = 0}>{t("View recap again ↺")}</button>
198          {/if}
199        </div>
200        <div class="story-bottom"><span>{String(slide + 1).padStart(2, '0')} <span class="muted">/ {cards.length}</span></span><div class="story-controls"><button aria-label={t("Previous chapter")} onclick={() => navigate(-1)} disabled={slide === 0}>←</button><button aria-label={t("Next chapter")} onclick={() => navigate(1)} disabled={slide === cards.length - 1}>→</button></div></div>
201      </section>
202      <details class="source-notes"><summary>{t("Behind your review · source & availability")}</summary><p>{message(review.source)}</p><p>{message(review.coverage)}</p><p>{t("Snapshot: {date} · Format v{version}", { date: new Date(review.generatedAt).toISOString(), version: review.schemaVersion })}</p>{#each review.warnings as warning}<p>{message(warning)}</p>{/each}</details>
203    {:else}<section class="state-screen"><h1>{t("Let’s try that again.")}</h1><button class="primary" onclick={bootstrap}>{t("Reload review")}</button></section>{/if}
204  </main>
205  {#if user}<footer><span>{t("MADE FOR THE LOVE OF THE WATCH.")}</span></footer>{/if}
206</div>
207
208{#if user && !loading && review?.ranking && card === 12}<Confetti />{/if}